<?php
/**
 * crcl/base
 *
 * Copyright 2013 CRCL - André Sabosch - info@crcl.de
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

namespace crcl\base\xml;

interface ISax
{
  /**
   * Returns instance (factory method) and sets xml
   *
   * @param string $sXml
   * @return \crcl\base\xml\ISax
   */
  public static function fromString($sXml);

  /**
   * Setter for xml data
   *
   * @param string $sXml
   */
  public function setXml($sXml);

  /**
   * Getter for xml data
   *
   * @return string
   */
  public function getXml();

  /**
   * Parse current xml
   *
   * @chainable
   * @return \crcl\base\xml\Sax
   */
  public function parse();

//events, should be overriden in child classes
  public function onDocumentStart();
  public function onDocumentEnd();
  public function onDocumentType($sDoctype);

  public function onElementData($sData);
  public function onElementStart($sTagName, $aAttributes, $bShort = \FALSE);
  public function onElementClose($sTagName);

  public function onComment($sComment);
  public function onCDATASection($sCDATA);

  public function onXmlDeclaration($sDeclaration);
  public function onProcessingInstruction($sInstruction);
}

abstract class Sax implements ISax
{
  public static function fromString($sXml)
  {
    $sClass = \get_called_class();
    $oSax = new $sClass($sXml);
    $oSax->setXml($sXml);
    return $oSax;
  }

  public function setXml($sXml)
  {
    //we dont like the BOM
    $sXml = \crcl\base\String::getInstance($sXml)
      ->removeBOM()
      ->toString();
    $this->sXml = $sXml;
  }

  public function getXml()
  {
    return $this->sXml;
  }

  public function parse()
  {
    try
    {
      $sSource = $this->sXml;

      $this->iLine = 1;
      $sState      = self::STATE_ROOT;
      $iMark       = 0;
      $iLength     = \strlen($sSource);

      $sQuoteStyle = '"';
      $sTagName    = '';
      $sAttribute  = '';
      $aAttributes = array();
      $bCustomDoctype = \FALSE;

      $this->onDocumentStart();

      for ($i = 0; $i < $iLength; $i++)
      {
        $c = \substr($sSource, $i, 1);

        switch ($sState)
        {
          case self::STATE_ROOT:
            if ($c === '<')
            {
              $iMark  = $i; // mark tag start
              $sState = self::STATE_LT;
            }
            elseif (!\crcl\base\string\Char::isWhitechar($c))
            {
              throw new Exception(
                'Characters found before beginning of document',
                Exception::ERROR_SAX_CHARSFOUNDBEFOREBEGINNING,
                array('char' => $c));
            }
            break;
          case self::STATE_TEXT:
            if ($c === '<')
            {
              if ($iMark != $i)
              {
                $this->onElementData(\substr($sSource, $iMark, $i - $iMark));
              }
              $iMark  = $i;
              $sState = self::STATE_LT;
            }
            break;
          case self::STATE_LT:
            if ($c === '/')
            {
              $iMark  = $i + 1;
              $sState = self::STATE_TAG_CLOSE;
            }
            elseif ($c === '?' and \strtolower(\substr($sSource, $i, 5)) === '?xml ')
            {
              $sState = self::STATE_XMLDEC;
            }
            elseif ($c === '?')
            {
              $sState = self::STATE_PREPROC;
            }
            elseif ($c === '!' and \substr($sSource, $i, 3) === '!--')
            {
              $sState = self::STATE_COMMENT;
            }
            elseif ($c === '!' and \substr($sSource, $i, 8) === '![CDATA[')
            {
              $sState = self::STATE_CDATA;
              $iMark  = $i + 8; // past opening tag
            }
            elseif ($c === '!' and \strtoupper(\substr($sSource, $i, 8)) === '!DOCTYPE')
            {
              $sState = self::STATE_DOCTYPE;
            }
            elseif (\crcl\base\string\Char::isWhitechar($c))
            {
              $sState = self::STATE_TEXT;
            }
            else
            {
              $iMark       = $i; // mark node name start
              $aAttributes = array();
              $sAttribute = '';
              $sState     = self::STATE_TAG_NAME;
            }
            break;
          case self::STATE_TAG_NAME:
            if (\crcl\base\string\Char::isWhitechar($c) || $c === '/' || $c === '>')
            {
              $sTagName = \substr($sSource, $iMark, $i - $iMark);
              if (!$this->isValidQName($sTagName))
              {
                throw new Exception(
                  'Invalid tag name',
                  Exception::ERROR_SAX_INVALIDTAGNAME,
                  array('tagname' => $sTagName));
              }
              if ($c === '/')
              {
                $sState = self::STATE_TAG_SINGLE;
              }
              elseif ($c === '>')
              {
                $iMark  = $i + 1; // mark text start
                $sState = self::STATE_TEXT;
                $this->onElementStart($sTagName, $aAttributes);
              }
              else /* isWhiteChar */
              {
                $sState = self::STATE_TAG_ATTRIBUTES;
              }
            }
            break;
          case self::STATE_TAG_CLOSE:
            if ($c === '>')
            {
              $sTagName = \rtrim(\substr($sSource, $iMark, $i - $iMark));
              $this->onElementClose($sTagName);
              $iMark    = $i + 1; // mark text start
              $sState   = self::STATE_TEXT;
            }
            break;
          case self::STATE_TAG_SINGLE:
            if ($c !== '>')
            {
              throw new Exception(
                'Close tag expected',
                Exception::ERROR_SAX_CLOSETAGEXPECTED,
                array('found' => $c, 'tagname' => $sTagName));
            }
            $iMark    = $i + 1;   // mark text start
            $sState   = self::STATE_TEXT;
            $this->onElementStart($sTagName, $aAttributes, \TRUE);
            $this->onElementClose($sTagName);
            break;
          case self::STATE_TAG_BETWEEN_ATTRIBUTE:
          case self::STATE_TAG_ATTRIBUTES:
            if ($c === '>')
            {
              $iMark  = $i + 1;   // mark text start
              $sState = self::STATE_TEXT;
              $this->onElementStart($sTagName, $aAttributes);
            }
            elseif ($c === '/')
            {
              $sState = self::STATE_TAG_SINGLE;
            }
            elseif (\crcl\base\string\Char::isWhiteChar($c))
            {
              $sState = self::STATE_TAG_ATTRIBUTES;
            }
            elseif ($sState === self::STATE_TAG_ATTRIBUTES && $this->isValidQName($c))
            {
              $iMark  = $i; // mark attribute key start
              $sState = self::STATE_ATTR_KEY;
            }
            else
            {
              throw new Exception(
                'Unexpected char',
                Exception::ERROR_SAX_UNEXPECTEDCHAR,
                array('char' => $c, 'tagname' => $sTagName));
            }
            break;
          case self::STATE_COMMENT:
            if ($c === '>' && $i > $iMark + 4 && \substr($sSource, $i - 2, 2) === '--')
            {
              if (\preg_match('/^-|--|-$/', \substr($sSource, $iMark + 4, $i - $iMark + 1 - 7)))
              {
                throw new Exception(
                  'Illegal comment',
                  Exception::ERROR_SAX_ILLEGALCOMMENT,
                  array('comment' => \substr($sSource, $iMark + 4, $i - $iMark + 1 - 7)));
              }
              $this->onComment(\substr($sSource, $iMark + 4, $i - $iMark + 1 - 7));
              $iMark    = $i + 1; // mark text start
              $sState   = self::STATE_TEXT;
            }
            break;
          case self::STATE_CDATA:
            if ($c === '>' and \substr($sSource, $i - 2, 2) === ']]')
            {
              $this->onCDATASection(\substr($sSource, $iMark, $i - $iMark - 2));
              $iMark  = $i + 1; // mark text start
              $sState = self::STATE_TEXT;
            }
            break;
          case self::STATE_XMLDEC:
            if ($c === '?' && \substr($sSource, $i, 2) === '?>')
            {
              $this->onXmlDeclaration(\substr($sSource, $iMark, $i - $iMark + 2));
              $i++; // skip '>'
              $iMark  = $i + 1; // mark text start
              $sState = self::STATE_TEXT;
            }
            break;
          case self::STATE_DOCTYPE:
            if ($c === '[')
            {
              $bCustomDoctype = \TRUE;
            }
            elseif ($bCustomDoctype && $c === '>' && \substr($sSource, $i - 1, 2) === ']>')
            {
              $bCustomDoctype = \FALSE;
              $this->onDocumentType(\substr($sSource, $iMark, $i - $iMark + 1));
              $iMark          = $i + 1; // mark text start
              $sState         = self::STATE_TEXT;
            }
            elseif (!$bCustomDoctype && $c === '>')
            {
              $bCustomDoctype = \FALSE;
              $this->onDocumentType(\substr($sSource, $iMark, $i - $iMark + 1));
              $iMark          = $i + 1; // mark text start
              $sState         = self::STATE_TEXT;
            }
            break;
          case self::STATE_PREPROC:
            if ($c === '>' and \substr($sSource, $i-1, 1) === '?')
            {
              $this->onProcessingInstruction(\substr($sSource, $iMark, $i - $iMark + 1));
              $iMark  = $i + 1; // mark text start
              $sState = self::STATE_TEXT;
            }
            break;
          case self::STATE_ATTR_KEY:
            if ($c === '=' || \crcl\base\string\Char::isWhitechar($c))
            {
              $sAttribute = \substr($sSource, $iMark, $i - $iMark);
              if (!$this->isValidQName($sAttribute))
              {
                throw new Exception(
                  'Invalid attribute name',
                  Exception::ERROR_SAX_INVALIDATTRIBUTENAME,
                  array('attribute' => $sAttribute, 'tagname' => $sTagName));
              }
              if (isset($aAttributes[$sAttribute]))
              {
                throw new Exception(
                  'Attribute doubled',
                  Exception::ERROR_SAX_DOUBLEATTRIBUTE,
                  array('attribute' => $sAttribute, 'tagname'   => $sTagName));
              }
              if ($c === '=')
              {
                $sState = self::STATE_ATTR_VALUE;
              }
              else /* white char */
              {
                $sState = self::STATE_ATTR_EQ;
              }
            }
            elseif ($c === '/' || $c === '>')
            {
              $sAttribute = \substr($sSource, $iMark, $i - $iMark);
              if (!$this->isValidQName($sAttribute))
              {
                throw new Exception(
                  'Invalid attribute name',
                  Exception::ERROR_SAX_INVALIDATTRIBUTENAME,
                  array('attribute' => $sAttribute));
              }
              throw new Exception(
                'Attribute without value',
                Exception::ERROR_SAX_ATTRIBUTEWITHOUTVALUE,
                array('attribute' => $sAttribute));
            }
            break;
          case self::STATE_ATTR_EQ:
            if ($c === '=')
            {
              $sState = self::STATE_ATTR_VALUE;
            }
            elseif (!\crcl\base\string\Char::isWhitechar($c))
            {
              throw new Exception(
                'Attribute without value',
                Exception::ERROR_SAX_ATTRIBUTEWITHOUTVALUE,
                array('attribute' => $sAttribute, 'tagname'   => $sTagName, 'char'      => $c));
            }
            break;
          case self::STATE_ATTR_VALUE:
            if (\crcl\base\string\Char::isWhiteChar($c))
            {

            }
            elseif ($c === '"' or $c === '\'')
            {
              $sQuoteStyle = $c;
              $sState      = self::STATE_ATTR_QUOTE;
              $iMark       = $i + 1; // mark attribute real value start
            }
            else
            {
              throw new Exception(
                'Missing quotes',
                Exception::ERROR_SAX_MISSINGQUOTES,
                array('attribute' => $sAttribute, 'tagname' => $sTagName, 'char' => $c));
            }
            break;
          case self::STATE_ATTR_QUOTE:
            if ($c === $sQuoteStyle)
            {
              $sAttributeValue = \substr($sSource, $iMark, $i - $iMark);
              $sAttributeValue = \crcl\base\String::getInstance($sAttributeValue)
                ->doNotStartWith('"')
                ->doNotEndWith('"')
                ->toString();
              $aAttributes[$sAttribute] = $sAttributeValue;
              $sState                   = self::STATE_TAG_BETWEEN_ATTRIBUTE;
            }
            break;
        }
      }
      if ($sState === self::STATE_TEXT) // allows text past root node, which is in violation of XML spec
      {
        if ($i > $iMark)
        {
          $sText = \substr($sSource, $iMark, $i - $iMark);
          if (!\ctype_space($sText))
          {
            throw new Exception(
              'Characters found after end of document',
              Exception::ERROR_SAX_CHARSFOUNDAFTEREND);
          }
        }
      }
      else
      {
        if ($sState === self::STATE_ROOT)
        {
          throw new Exception('No tags found',
            Exception::ERROR_SAX_NOTAGSFOUND);
        }
        else
        {
          throw new Exception(
            'Unexpected state',
            Exception::ERROR_SAX_UNEXPECTEDSTATE,
            array('state' => $sState));
        }
      }
      $this->onDocumentEnd();
    }

    catch (Exception $e)
    {
      $sSample = $this->getSample($i, $sSource);

      $e
        ->addCause('line', $this->iLine)
        ->addCause('pos', $i)
        ->addCause('sample', $sSample);

      throw $e;
    }
    return $this;
  }


  /** PRIVATE *************************************************************************************/

  const STATE_ROOT = 0;
  const STATE_TEXT = 1;
  const STATE_LT   = 2;
  const STATE_TAG_NAME = 3;
  const STATE_TAG_CLOSE = 4;
  const STATE_TAG_SINGLE = 5;
  const STATE_TAG_ATTRIBUTES = 6;
  const STATE_TAG_BETWEEN_ATTRIBUTE = 7;
  const STATE_CDATA = 8;
  const STATE_COMMENT = 9;
  const STATE_DOCTYPE = 10;
  const STATE_XMLDEC = 11;
  const STATE_PREPROC = 12;
  const STATE_ATTR_KEY = 13;
  const STATE_ATTR_EQ = 14;
  const STATE_ATTR_QUOTE = 15;
  const STATE_ATTR_VALUE = 16;

  private $sXml = \NULL;
  private $iLine = 0;

  private function isValidQName($sName)
  {
    return preg_match(
      '/^([a-z_\x80-\xff]+[a-z0-9._\x80-\xff-]*:)?[a-z_\x80-\xff]+[a-z0-9._\x80-\xff-]*$/i',
      $sName);
  }

  private function getSample($i, $sSource)
  {
    $iLength = \strlen($sSource);

    if ($i >= 20)
    {
      $iStart = $i-20;
    }
    else
    {
      $iStart = 0;
    }
    if ($i <= $iLength - 20)
    {
      $iEnd = $i+20;
    }
    else
    {
      $iEnd = $iLength;
    }
    $sSample = \substr($sSource, $iStart, $iEnd-$iStart);
    return $sSample;
  }

}
